0%

用 C# .NET Core 爬取個股日本益比、殖利率及股價淨值比 + 簡易回測 | 用程式打造選股策略(6)

前言

在「用程式打造選股策略」這一系列文的前面幾篇,已經爬了許多基本資料下來,
但如果想要做基本面回測,還必須得從當天股價的和當時的月報、季報來計算當時的本益比、殖利率、股價淨值比,相當的麻煩。

不過我們的證交所真的是很佛心,這些資訊全部都已經幫你整理好了,甚至還可以抓到歷史資料,
位置: 台灣證券交易所-個股日本益比、殖利率及股價淨值比

不囉嗦,先把資料爬下來再說

爬取歷史每日 - 個股日本益比、殖利率及股價淨值比

觀察網站的部分這邊就省略了,就是下載CSV檔案而已,應該很容易吧!

NET Core Encoding 問題

這邊補充一些前面幾篇可能也有的問題
因為 .NET Core 為了瘦身,將不常用的編碼放到Nuget包System.Text.Encoding.CodePages
這會造成我們在讀取Big5編碼時出現錯誤,因此我們必須將編碼引入,如下:

1
dotnet add package System.Text.Encoding.CodePages --version 4.7.0

引用Encoding:

1
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

爬蟲部分

爬取CSV,直接讀成string型態回傳,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public async Task<string> GetCsvAsync(DateTime date)
{
using(var client = _clientFactory.CreateClient())
{
var response = await client.GetAsync(
$"https://www.twse.com.tw/exchangeReport/BWIBBU_d?response=csv&date={date.ToString("yyyyMMdd")}&selectType=ALL"
);
var bytes = await response.Content.ReadAsByteArrayAsync();
var result = Encoding.GetEncoding(950).GetString(bytes);
if(response.StatusCode != System.Net.HttpStatusCode.OK)
throw new PlatformNotSupportedException($"目前無法爬取每日基本面資料...,{response.StatusCode}{result}");
return result;
}
}

解析CSV資料

一樣使用 CsvHelper 套件,安裝方法可以參考: 用 C# .NET Core 爬取每月財報 | 用程式打造選股策略(4)

首先我們建立DB Model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Table("FundamentalDaily")]
public class FundamentalDaily
{
//"證券代號","證券名稱","殖利率(%)","股利年度","本益比","股價淨值比","財報年/季"
[ExplicitKey]
public DateTime date { get; set; }
[ExplicitKey]
[Name("證券代號")]
public string stock_id { get; set; }
[Name("殖利率(%)")]
public decimal? dividend_yield { get; set; }
[Name("本益比")]
public decimal? pe_ratio { get; set; }
[Name("股價淨值比")]
public decimal? price_book_ratio { get; set; }
}

由於CSV檔案內,會有些不存在的資料會用「-」號來表示,
根據 CsvHelper官方文件,我們可以繼承 ClassMap 來將過濾掉一些這些不正確的值!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class FundamentalDailyMap: ClassMap<FundamentalDaily>
{
public FundamentalDailyMap()
{
AutoMap(CultureInfo.InvariantCulture);
Map(m => m.date).Ignore();
Map(m => m.dividend_yield).ConvertUsing(row => {
var field = row.GetField("殖利率(%)");
if(!field.ToString().Contains("-"))
return Convert.ToDecimal(field);
else
return null;
});
Map(m => m.pe_ratio).ConvertUsing(row => {
var field = row.GetField("本益比");
if(!field.ToString().Contains("-"))
return Convert.ToDecimal(field);
else
return null;
});
Map(m => m.price_book_ratio).ConvertUsing(row => {
var field = row.GetField("股價淨值比");
if(!field.ToString().Contains("-"))
return Convert.ToDecimal(field);
else
return null;
});
}
}

然後我們將剛剛的string傳入,
並且直接讀成IEnumerable<FundamentalDaily>
這樣就可以等等就可以直接存進資料庫!

不過這份CSV的頭尾都有一些說明資訊,
所以這裡一行一行的讀取,並且用 Regex 將不合法的資料過濾掉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public IEnumerable<FundamentalDaily> ReadCsv(string data)
{
using (var reader = new StringReader(data))
using (var csvReader = new CsvReader(reader, CultureInfo.InvariantCulture))
{
csvReader.Configuration.RegisterClassMap<FundamentalDailyMap>();
while (csvReader.Read())
{
FundamentalDaily fundamentalDaily = null;
try
{
if(!Regex.IsMatch(csvReader.Context.RawRecord,".*,\r\n")) // 過濾不正常資料
continue;
fundamentalDaily = csvReader.GetRecord<FundamentalDaily>();
}
catch (CsvHelper.TypeConversion.TypeConverterException ex)
{
_logger.LogDebug(ex.Message);
}
catch (Exception ex)
{
_logger.LogWarning(ex.Message);
}
if(fundamentalDaily != null)
yield return fundamentalDaily;
}
}
}

存入資料庫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class FundamentalDailyRepository
{
private readonly SqlConnection _conn;
private readonly ILogger<FundamentalDailyRepository> _logger;
public FundamentalDailyRepository(ILogger<FundamentalDailyRepository> logger, SqlConnection conn)
{
_logger = logger;
_conn = conn;
}

public void Insert(IEnumerable<FundamentalDaily> fundamentalDailyList)
{
try
{
using (var scope = new TransactionScope())
{
foreach (var fundamentalDaily in fundamentalDailyList)
{
_conn.Insert(fundamentalDaily);
}
scope.Complete();
}
}
catch (Exception ex)
{
_logger.LogError(ex.Message);
}
}

public IEnumerable<FundamentalDaily> GetByDate(DateTime date)
{
return _conn.Query<FundamentalDaily>(
"select * from FundamentalDaily where date=@date", new { date }
);
}

public bool IsExist(DateTime date)
{
return _conn.ExecuteScalar<bool>(
"select count(1) from FundamentalDaily where date=@date",
new { date }
);
}
}

存入整理的過程這裡就不寫了,反正就只是呼叫Insert方法而已

選股 & 簡易回測

接著設計一個選股方法,方便以後篩選使用

首先,我們知道
本益比、股價淨值比 越小越好,
殖利率 => 越大越好

另外,為了避免傳入的日期是假日造成沒有資料,所以只要沒有資料,就自動將日期往前移一天,直到有資料為止

程式如下:

1
2
3
4
5
6
7
8
9
10
public IEnumerable<FundamentalDaily> GetFundamentalDailyList(DateTime date, int pe_ratio, int price_book_ratio, int dividend_yield)
{
IEnumerable<FundamentalDaily> fundamentalDailyList = null;
for(int i=0; fundamentalDailyList == null || fundamentalDailyList.Count() == 0; i++)
fundamentalDailyList = _fundamentalDailyRepository.GetByDate(date.AddDays(-i));
return fundamentalDailyList.Where(fundamentalDaily =>
fundamentalDaily.pe_ratio < pe_ratio &&
fundamentalDaily.price_book_ratio < price_book_ratio &&
fundamentalDaily.dividend_yield > dividend_yield);
}

簡易回測:

首先根據剛剛的方法,篩選出這些條件:

  • 本益比 < 15
  • 股價淨值比 < 2
  • 殖利率 > 4
1
GetFundamentalDailyList(new DateTime(year, month, day), 15, 2, 4);

由於這樣資料還是太多,所以我額外篩選了一些條件如下:

  • 月營收 > 前月營收
  • 月營收 > 前年同月營收
  • EPS > 前季EPS
  • 股價 > 10
  • 股價 < 50

測試看看,每年1月1號,選出一批股票,到年底結算報酬率
結果:

年度 平均年化報酬率 篩選出的股票
2016 19.57% 1229,1442,1513,1730,2062,2107,2347,2359,2414,2433,2459,2468,2483,2488,2527,2535,2542,3005,3010,3022,3032,4720,4999,5471,6136,6192,6213,6449,9911,9924,9925,9933
2017 NaN 無符合條件
2018 -1.95% 1615, 1712, 2024, 2034, 2414, 2471, 2493, 3021, 3028, 4722, 6112, 6196, 6201, 8103, 8163, 8210, 9924
2019 20.22% 1104, 1452, 1530, 1710, 1737, 2006, 2108, 2340, 2356, 2433, 2480, 2488, 2511, 2542, 2904, 3003, 3029, 3231, 4155, 4532, 5522, 6112, 6184, 6582, 8215, 8497, 9924, 9945, 9946,

整體來看,報酬率都還算不錯,
雖然2018年是負的,但回顧大盤,當年是因為10月份有一個大波段的崩盤 (印象中記得是中美貿易戰吧)
在崩這麼慘的情況下平均報酬率也才僅僅 -1.95% 算是相當厲害了

結語

回測結果證明了,基本面選出來的股票,報酬率算相當不錯,
實際操作上雖然還是需要一定的資金,不過已經將股價壓到50元以下的情況,應該有機會實際應用才對..

最近還想把這些資料做成網站的形式呈現,不曉得能不能弄出點什麼來…QQ

↓↓↓ 如果喜歡我的文章,可以幫我按個Like! ↓↓↓
>> 或者,請我喝杯咖啡,這樣我會更有動力唷! <<<
街口支付

街口支付

街口帳號: 901061546

歡迎關注我的其它發布渠道